#!/bin/bash -p

# Copyright (c) 2012 The Chromium Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.

# usage: dmgdiffer.sh product_name old_dmg new_dmg patch_dmg
#
# dmgdiffer creates a disk image containing a binary update able to patch
# a product originally distributed in old_dmg to the version in new_dmg. Much
# of this script is generic, but the make_patch_fs function is specific to
# a product: in this case, Google Chrome.
#
# This script operates by mounting old_dmg and new_dmg, creating a new
# filesystem structure containing dirpatches generated by dirdiffer and
# goobsdiff (which should be located in the same directory as this script),
# and producing a disk image from that structure.
#
# The Chrome make_patch_fs function produces an disk image that is able to
# update a single old version on any Keystone channel to a new version on a
# specific Keystone channel (the Keystone channel associated with new_dmg).
# Chrome's updates are split into two dirpatches: one updates the old
# versioned directory to the new one, and the other updates the remainder of
# the application. The versioned directory is split out from the rest because
# it contains the bulk of the application and its name changes from version to
# version, and dirdiffer/dirpatcher do not directly handle name changes. This
# approach also allows the versioned directory dirpatch to be applied in-place
# in most cases during an update, rather than relying on a temporary
# directory. In order to allow a single update dmg to apply to an old version
# on any Keystone channel, several small files are never distributed as diffs,
# and only as full (possibly compressed) versions of the new files. These
# files include the outer application's Info.plist which contains Keystone
# channel information, and anything created or modified by code-signing the
# outer application.
#
# Application of update disk images produced by this script is
# product-specific. With updates managed by Keystone, the update disk images
# can contain a .keystone_install script that is able to locate and update
# the installed product.
#
# Exit codes:
#  0  OK
#  1  Unknown failure
#  2  Incorrect number of parameters
#  3  Input disk images do not exist
#  4  Output disk image already exists
#  5  Parent of output directory does not exist or is not a directory
#  6  Could not mount old_dmg
#  7  Could not mount new_dmg
#  8  Could not create temporary patch filesystem directory
#  9  Could not create disk image
# 10  Could not read old application data
# 11  Could not read new application data
# 12  Old or new application sanity check failure
# 13  Could not write the patch
#
# Exit codes in the range 21-40 are mapped to codes 1-20 as returned by the
# first dirdiffer invocation. Codes 41-60 are mapped to codes 1-20 as returned
# by the second.

set -eu

# Environment sanitization. Set a known-safe PATH. Clear environment variables
# that might impact the interpreter's operation. The |bash -p| invocation
# on the #! line takes the bite out of BASH_ENV, ENV, and SHELLOPTS (among
# other features), but clearing them here ensures that they won't impact any
# shell scripts used as utility programs. SHELLOPTS is read-only and can't be
# unset, only unexported.
export PATH="/usr/bin:/bin:/usr/sbin:/sbin"
unset BASH_ENV CDPATH ENV GLOBIGNORE IFS POSIXLY_CORRECT
export -n SHELLOPTS

ME="$(basename "${0}")"
readonly ME
SCRIPT_DIR="$(dirname "${0}")"
readonly SCRIPT_DIR
readonly DIRDIFFER="${SCRIPT_DIR}/dirdiffer.sh"
readonly PKG_DMG="${SCRIPT_DIR}/pkg-dmg"

err() {
  local error="${1}"

  echo "${ME}: ${error}" >& 2
}

declare -a g_cleanup g_cleanup_mount_points
cleanup() {
  local status=${?}

  trap - EXIT
  trap '' HUP INT QUIT TERM

  if [[ ${status} -ge 128 ]]; then
    err "Caught signal $((${status} - 128))"
  fi

  if [[ "${#g_cleanup_mount_points[@]}" -gt 0 ]]; then
    local mount_point
    for mount_point in "${g_cleanup_mount_points[@]}"; do
      hdiutil detach "${mount_point}" -force >& /dev/null || true
    done
  fi

  if [[ "${#g_cleanup[@]}" -gt 0 ]]; then
    rm -rf "${g_cleanup[@]}"
  fi

  exit ${status}
}

mount_dmg() {
  local dmg="${1}"
  local mount_point="${2}"

  if ! hdiutil attach "${1}" -mountpoint "${2}" \
                             -nobrowse -owners off > /dev/null; then
    # set -e is in effect. return ${?} so that the caller can check the return
    # code if desired, perhaps to print a more useful error message or to exit
    # with a more precise status than would be possible here.
    return ${?}
  fi
}

# make_patch_fs is responsible for comparing the old and new disk images
# mounted at old_fs and new_fs, respectively, and populating patch_fs with the
# contents of what will become a disk image able to update old_fs to new_fs.
# It then outputs a string which will be used as the volume name of the
# patch_dmg.
#
# The entire patch contents are placed into a .patch directory to hide them
# from ordinary view. The disk image will be given a volume name like
# "Google Chrome 5.0.375.55-5.0.375.70" as an identifying aid, although
# uniqueness is not important and users will never interact directly with
# them.
make_patch_fs() {
  local product_name="${1}"
  local old_fs="${2}"
  local new_fs="${3}"
  local patch_fs="${4}"

  readonly APP_NAME="${product_name}.app"
  readonly APP_NAME_RE="${product_name}\\.app"
  readonly APP_PLIST="Contents/Info"
  readonly APP_VERSION_KEY="CFBundleShortVersionString"
  readonly KS_VERSION_KEY="KSVersion"
  readonly KS_PRODUCT_KEY="KSProductID"
  readonly KS_CHANNEL_KEY="KSChannelID"
  readonly VERSIONS_DIR_OLD="Contents/Versions"
  readonly BUILD_RE="^[0-9]+\\.[0-9]+\\.([0-9]+)\\.[0-9]+\$"
  readonly MIN_BUILD=434

  local versions_dir_new
  local product_url
  local is_sxs_capable
  if [[ "${product_name}" = "Google Chrome Beta" ]]; then
    versions_dir_new=\
"Contents/Frameworks/Google Chrome Framework.framework/Versions"
    product_url="https://www.google.com/chrome/beta/"
    is_sxs_capable="y"
  elif [[ "${product_name}" = "Google Chrome Dev" ]]; then
    versions_dir_new=\
"Contents/Frameworks/Google Chrome Framework.framework/Versions"
    product_url="https://www.google.com/chrome/dev/"
    is_sxs_capable="y"
  elif [[ "${product_name}" = "Google Chrome Canary" ]]; then
    versions_dir_new=\
"Contents/Frameworks/Google Chrome Framework.framework/Versions"
    product_url="https://www.google.com/chrome/canary/"
    is_sxs_capable="y"
  else
    versions_dir_new=\
"Contents/Frameworks/${product_name} Framework.framework/Versions"
    product_url="https://www.google.com/chrome/"
  fi

  local old_app_path="${old_fs}/${APP_NAME}"
  local old_app_plist="${old_app_path}/${APP_PLIST}"
  local old_app_version
  if ! old_app_version="$(defaults read "${old_app_plist}" \
                                        "${APP_VERSION_KEY}")"; then
    err "could not read old app version"
    exit 10
  fi
  if ! [[ "${old_app_version}" =~ ${BUILD_RE} ]]; then
    err "old app version not of expected format"
    exit 10
  fi
  local old_app_version_build="${BASH_REMATCH[1]}"

  local old_ks_plist="${old_app_plist}"
  local old_ks_version
  if ! old_ks_version="$(defaults read "${old_ks_plist}" \
                                       "${KS_VERSION_KEY}")"; then
    err "could not read old Keystone version"
    exit 10
  fi

  local new_app_path="${new_fs}/${APP_NAME}"
  local new_app_plist="${new_app_path}/${APP_PLIST}"
  local new_app_version
  if ! new_app_version="$(defaults read "${new_app_plist}" \
                      "${APP_VERSION_KEY}")"; then
    err "could not read new app version"
    exit 11
  fi
  if ! [[ "${new_app_version}" =~ ${BUILD_RE} ]]; then
    err "new app version not of expected format"
    exit 11
  fi
  local new_app_version_build="${BASH_REMATCH[1]}"

  local new_ks_plist="${new_app_plist}"
  local new_ks_version
  if ! new_ks_version="$(defaults read "${new_ks_plist}" \
                                       "${KS_VERSION_KEY}")"; then
    err "could not read new Keystone version"
    exit 11
  fi

  local new_ks_product
  if ! new_ks_product="$(defaults read "${new_app_plist}" \
                                       "${KS_PRODUCT_KEY}")"; then
    err "could not read new Keystone product ID"
    exit 11
  fi

  if [[ ${old_app_version_build} -lt ${MIN_BUILD} ]] ||
     [[ ${new_app_version_build} -lt ${MIN_BUILD} ]]; then
    err "old and new versions must be build ${MIN_BUILD} or newer"
    exit 12
  fi

  local new_ks_channel
  new_ks_channel="$(defaults read "${new_app_plist}" \
                    "${KS_CHANNEL_KEY}" 2> /dev/null || true)"

  # Side-by-side flavors have the channel already baked into the app name; non
  # side-by-side beta and dev flavors need the channel name tacked on later.
  local name_extra
  if [[ -n "${is_sxs_capable}" ]]; then
    name_extra=
  elif [[ "${new_ks_channel}" = "beta" ]]; then
    name_extra=" Beta"
  elif [[ "${new_ks_channel}" = "dev" ]]; then
    name_extra=" Dev"
  elif [[ -n "${new_ks_channel}" ]]; then
    name_extra=" ${new_ks_channel}"
  fi

  local old_versioned_dir
  if [[ -e "${old_app_path}/${versions_dir_new}" ]]; then
    old_versioned_dir="${old_app_path}/${versions_dir_new}/${old_app_version}"
  else
    old_versioned_dir="${old_app_path}/${VERSIONS_DIR_OLD}/${old_app_version}"
  fi

  local new_versioned_dir
  local layout_new
  if [[ -e "${new_app_path}/${versions_dir_new}" ]]; then
    new_versioned_dir="${new_app_path}/${versions_dir_new}/${new_app_version}"
    layout_new="y"
  else
    new_versioned_dir="${new_app_path}/${VERSIONS_DIR_OLD}/${new_app_version}"
  fi

  if ! cp -p "${SCRIPT_DIR}/keystone_install.sh" \
             "${patch_fs}/.keystone_install"; then
    err "could not copy .keystone_install"
    exit 13
  fi

  local patch_dotpatch_dir="${patch_fs}/.patch"
  if ! mkdir "${patch_dotpatch_dir}"; then
    err "could not mkdir patch_dotpatch_dir"
    exit 13
  fi

  if ! cp -p "${SCRIPT_DIR}/dirpatcher.sh" \
             "${SCRIPT_DIR}/goobspatch" \
             "${SCRIPT_DIR}/liblzma_decompress.dylib" \
             "${SCRIPT_DIR}/xzdec" \
             "${patch_dotpatch_dir}/"; then
    err "could not copy patching tools"
    exit 13
  fi

  if ! echo "${new_ks_product}" > "${patch_dotpatch_dir}/ks_product" ||
     ! echo "${old_app_version}" > "${patch_dotpatch_dir}/old_app_version" ||
     ! echo "${new_app_version}" > "${patch_dotpatch_dir}/new_app_version" ||
     ! echo "${old_ks_version}" > "${patch_dotpatch_dir}/old_ks_version" ||
     ! echo "${new_ks_version}" > "${patch_dotpatch_dir}/new_ks_version"; then
    err "could not write patch product or version information"
    exit 13
  fi
  local patch_ks_channel_file="${patch_dotpatch_dir}/ks_channel"
  if [[ -n "${new_ks_channel}" ]]; then
    if ! echo "${new_ks_channel}" > "${patch_ks_channel_file}"; then
      err "could not write Keystone channel information"
      exit 13
    fi
  else
    if ! touch "${patch_ks_channel_file}"; then
      err "could not write empty Keystone channel information"
      exit 13
    fi
  fi

  # The only visible contents of the disk image will be a README file that
  # explains the image's purpose.
  local new_app_version_extra="${new_app_version}${name_extra}"
  cat > "${patch_fs}/README.txt" << __EOF__ || \
      (err "could not write README.txt" && exit 13)
This disk image contains a differential updater that can update
${product_name} from version ${old_app_version} to ${new_app_version_extra}.

This image is part of the auto-update system and is not independently useful.

To install ${product_name}, please visit ${product_url}.
__EOF__

  # version_patch_name is how keystone_install.sh distinguishes between diff
  # updates intended to place the versioned directory in the new layout
  # ("framework") and the old ("version").
  local version_patch_name
  if [[ -n "${layout_new}" ]]; then
    version_patch_name="framework"
  else
    version_patch_name="version"
  fi

  local patch_versioned_dir="${patch_dotpatch_dir}/\
${version_patch_name}_${old_app_version}_${new_app_version}.dirpatch"

  if ! "${DIRDIFFER}" "${old_versioned_dir}" \
                      "${new_versioned_dir}" \
                      "${patch_versioned_dir}"; then
    local status=${?}
    err "could not create a dirpatch for the versioned directory"
    exit $((${status} + 20))
  fi

  # Set DIRDIFFER_EXCLUDE to exclude the contents of the Versions directory,
  # but to include an empty Versions directory. The versioned directory was
  # already addressed in the preceding dirpatch.
  if [[ -n "${layout_new}" ]]; then
    # Transform versions_dir_new and the version into a regular expression
    # pattern by backslashing the dots.
    export DIRDIFFER_EXCLUDE=\
"/${APP_NAME_RE}/$(echo "${versions_dir_new}/${new_app_version}" |
                       sed -e 's/\./\\./g')"
  else
    # VERSIONS_DIR_OLD doesn't contain anything that a regular expression parser
    # would misinterpret.
    export DIRDIFFER_EXCLUDE="/${APP_NAME_RE}/${VERSIONS_DIR_OLD}/"
  fi

  # Set DIRDIFFER_NO_DIFF to exclude files introduced by or modified by
  # Keystone channel and brand tagging and subsequent code signing.
  export DIRDIFFER_NO_DIFF=\
"/${APP_NAME_RE}/Contents/\
(CodeResources|Info\\.plist|MacOS/${product_name}|_CodeSignature/.*)$"

  local patch_app_dir="${patch_dotpatch_dir}/application.dirpatch"

  if ! "${DIRDIFFER}" "${old_app_path}" \
                      "${new_app_path}" \
                      "${patch_app_dir}"; then
    local status=${?}
    err "could not create a dirpatch for the application directory"
    exit $((${status} + 40))
  fi

  unset DIRDIFFER_EXCLUDE DIRDIFFER_NO_DIFF

  echo "${product_name} ${old_app_version}-${new_app_version_extra} Update"
}

# package_patch_dmg creates a disk image at patch_dmg with the contents of
# patch_fs. The disk image's volume name is taken from volume_name. temp_dir
# is a work directory such as /tmp for the packager's use.
package_patch_dmg() {
  local patch_fs="${1}"
  local patch_dmg="${2}"
  local volume_name="${3}"
  local temp_dir="${4}"

  # Because most of the contents of ${patch_fs} are already compressed, the
  # overall compression on the disk image is mostly used to minimize the sizes
  # of the filesystem structures. In the presence of so much
  # already-compressed data, zlib performs better than bzip2, so use UDZO.
  if ! "${PKG_DMG}" \
           --verbosity 0 \
           --source "${patch_fs}" \
           --target "${patch_dmg}" \
           --tempdir "${temp_dir}" \
           --format UDZO \
           --volname "${volume_name}" \
           --config "openfolder_bless=0"; then
    err "disk image creation failed"
    exit 9
  fi
}

# make_patch_dmg mounts old_dmg and new_dmg, invokes make_patch_fs to prepare
# a patch filesystem, and then hands the patch filesystem to package_patch_dmg
# to create patch_dmg.
make_patch_dmg() {
  local product_name="${1}"
  local old_dmg="${2}"
  local new_dmg="${3}"
  local patch_dmg="${4}"

  local temp_dir
  temp_dir="$(mktemp -d -t "${ME}")"
  g_cleanup+=("${temp_dir}")

  local old_mount_point="${temp_dir}/old"
  g_cleanup_mount_points+=("${old_mount_point}")
  if ! mount_dmg "${old_dmg}" "${old_mount_point}"; then
    err "could not mount old_dmg ${old_dmg}"
    exit 6
  fi

  local new_mount_point="${temp_dir}/new"
  g_cleanup_mount_points+=("${new_mount_point}")
  if ! mount_dmg "${new_dmg}" "${new_mount_point}"; then
    err "could not mount new_dmg ${new_dmg}"
    exit 7
  fi

  local patch_fs="${temp_dir}/patch"
  if ! mkdir "${patch_fs}"; then
    err "could not mkdir patch_fs ${patch_fs}"
    exit 8
  fi

  local volume_name
  volume_name="$(make_patch_fs "${product_name}" \
                               "${old_mount_point}" \
                               "${new_mount_point}" \
                               "${patch_fs}")"

  hdiutil detach "${new_mount_point}" > /dev/null
  unset g_cleanup_mount_points[${#g_cleanup_mount_points[@]}]

  hdiutil detach "${old_mount_point}" > /dev/null
  unset g_cleanup_mount_points[${#g_cleanup_mount_points[@]}]

  package_patch_dmg "${patch_fs}" "${patch_dmg}" "${volume_name}" "${temp_dir}"

  rm -rf "${temp_dir}"
  unset g_cleanup[${#g_cleanup[@]}]
}

# shell_safe_path ensures that |path| is safe to pass to tools as a
# command-line argument. If the first character in |path| is "-", "./" is
# prepended to it. The possibly-modified |path| is output.
shell_safe_path() {
  local path="${1}"
  if [[ "${path:0:1}" = "-" ]]; then
    echo "./${path}"
  else
    echo "${path}"
  fi
}

usage() {
  echo "usage: ${ME} product_name old_dmg new_dmg patch_dmg" >& 2
}

main() {
  local product_name old_dmg new_dmg patch_dmg
  product_name="${1}"
  old_dmg="$(shell_safe_path "${2}")"
  new_dmg="$(shell_safe_path "${3}")"
  patch_dmg="$(shell_safe_path "${4}")"

  trap cleanup EXIT HUP INT QUIT TERM

  if ! [[ -f "${old_dmg}" ]] || ! [[ -f "${new_dmg}" ]]; then
    err "old_dmg and new_dmg must exist and be files"
    usage
    exit 3
  fi

  if [[ -e "${patch_dmg}" ]]; then
    err "patch_dmg must not exist"
    usage
    exit 4
  fi

  local patch_dmg_parent
  patch_dmg_parent="$(dirname "${patch_dmg}")"
  if ! [[ -d "${patch_dmg_parent}" ]]; then
    err "patch_dmg parent directory must exist and be a directory"
    usage
    exit 5
  fi

  make_patch_dmg "${product_name}" "${old_dmg}" "${new_dmg}" "${patch_dmg}"

  trap - EXIT
}

if [[ ${#} -ne 4 ]]; then
  usage
  exit 2
fi

main "${@}"
exit ${?}
